# 单独运行脚本或 通过django manager 终端`python manage.py scrawl`。
import os
import sys
import re
import json
import time
import logging
from io import BytesIO
from datetime import datetime

import requests
from lxml import etree
from lxml.html.clean import Cleaner

import django
from django.core.files import File
from django.utils import timezone
from django.contrib.auth.hashers import make_password, check_password

# ►启动环境
# 添加项目目录到系统环境变量path。pycharm运行脚本会自动添加，终端需要手动添加，否则找不到myapp.settings模块。
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.append(BASE_DIR)

# django环境外独立脚本需要设置settings模块路径到环境变量和初始化
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'gp_news.settings')
django.setup()

# 引入models
from news.models import News, Catalog, Image, Comment, images_directory_path
from django.contrib.auth.models import User

# 日志  设置为DEBUG级别会打印接口信息和一些变量
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)


# ►正式逻辑开始
logger.info('开始执行 spider/crawl.py 爬取网易新闻信息流区域新闻到数据库...')
# 需求： 爬取新闻网站新闻列表，这里选择网页端网易新闻news.163.com的下部信息流新闻，这些数据很规整。
#       不考虑上千万条的、高并发、去重等复杂情况。选定这个区域的几个目录、每个目录下的几十条新闻进行抓取，不考虑下一页。
# 分析： 数据存在于接口返回的js中，并不适用于请求得到静态页面后再根据dom节点xpath抓取，应该直接请求接口。

# 爬取信息流模块新闻。分类目录固定，不必自动添加，在下方定死。每个分类下一次请求返回70条新闻列表，不考虑翻页。不考虑'本地新闻'因为会跳转到另一个新闻站。
CATALOGS = [
    {'raw_name': 'yaowen20200213', 'name': '要闻', 'raw_url': 'https://temp.163.com/special/00804KVA/cm_yaowen20200213.js'},
    {'raw_name': 'guonei', 'name': '国内', 'raw_url': 'https://temp.163.com/special/00804KVA/cm_guonei.js'},
    {'raw_name': 'guoji', 'name': '国际', 'raw_url': 'https://temp.163.com/special/00804KVA/cm_guoji.js'},
    {'raw_name': 'dujia', 'name': '独家', 'raw_url': 'https://temp.163.com/special/00804KVA/cm_dujia.js'},
    {'raw_name': 'war', 'name': '军事', 'raw_url': 'https://temp.163.com/special/00804KVA/cm_war.js'},
    {'raw_name': 'money', 'name': '金融', 'raw_url': 'https://temp.163.com/special/00804KVA/cm_money.js'},
    {'raw_name': 'tech', 'name': '科技', 'raw_url': 'https://temp.163.com/special/00804KVA/cm_tech.js'},
    # 废弃 {'raw_name': 'zhengzhou_xw_news_v1', 'name': '本地新闻', 'raw_url': 'https://house.163.com/special/00078GU7/zhengzhou_xw_news_v1.js'},
]
# 一个目录最多取70条新闻，调低值以方便测试。
CRAWL_COUNT_PER_CATALOG = 20

# 持久化目录表
for c_row in CATALOGS:
    catalog, created = Catalog.objects.get_or_create(**c_row)
    logger.debug(f'{Catalog.objects.all()}')

# 反爬、伪造headers
# 经测试没有反爬措施，可以连续请求、不必伪造请求头和cookie。
# 万一被封ip，换不同手机热点。
INTERVAL_TIME = 0.1    # 请求间隔s
TIME_OUT = 3    # 超时时间
headers = {
    # 'host': 'temp.163.com',   # 首页temp.163.com  一条新闻详情news.163.com
    'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36',
    'refer': 'https://news.163.com/'
}
cookies = {}
params = {
    # 对于新闻列表接口，有无参数都返回jsonp格式。
    'callback': 'data_callback'
}

# ►▷☐◼︎
# ►请求一个目录下新闻列表
for catalog_url in [c['raw_url'] for c in CATALOGS]:
    _catalog = re.match(pattern=r'.+/cm_(\w+)\.js', string=catalog_url).group(1)
    catalog = Catalog.objects.get(raw_name=_catalog)
    logger.info('-'*200)
    logger.info(f'开始请求目录：{_catalog}')

    try:
        # 请求接口，返回jsonp文本
        resp = requests.get(catalog_url, params=params, headers=headers, timeout=TIME_OUT)
        if resp.status_code != 200:
            logger.debug(f'返回内容 {resp.text}')    # 或resp.data.decode(encoding='GB2312')
            raise Exception(f'状态码{resp.status_code}')

        # 接口返回数据样例 项目下/scratches/request_news_list_response.json
        posts_jsonp = resp.text
        jsonp_to_json_pattern = r'\w+[(]{1}([\w\W]*)[)]{1}'
        posts_json = re.findall(jsonp_to_json_pattern, posts_jsonp)[0]
        # 小概率json值中含双引号导致解析失败。退出整个目录爬取。 scratches/i.py 提供解决方案但这里不成功。
        posts = json.loads(posts_json)
        posts = posts[:CRAWL_COUNT_PER_CATALOG-1]
        posts_list_length = len(posts)
        logger.debug(f'news_list: {posts}')
    except Exception as e:
        logger.info(f'请求列表接口{resp.url}失败，异常{e}')
        continue



    # ▷每条新闻
    for i, post in enumerate(posts):
        try:
            time.sleep(INTERVAL_TIME)
            # 将要插入表的字段，详见news/model.py字段定义
            _post_id = post['commenturl'].split('/')[-1].split('.')[0]
            _title = post['title']
            _cover_url = post['imgurl'] if post['imgurl'] else None   # 有时值为''
            _publish_time = post.get('time', None)    # '05/22/2020 16:27:22'非正规时间格式需要转换 或None
            if _publish_time:
                _publish_time = timezone.make_aware(datetime.strptime(_publish_time, '%m/%d/%Y %H:%M:%S'))
            else:
                _publish_time = timezone.now()
            _document_url = post['docurl']
            _comment_url = post.get('commenturl', None)     # 有时原始数据缺少一些字段

            logger.info(f'当前目录{_catalog} - 进度[{i+1}/{posts_list_length}] - 新闻id {_post_id} url {_document_url}')

            # 十分之一的网页详情来自另一个新闻站，忽略掉。
            if _document_url.startswith('http://dy.163.com/v2/article'):
                raise Exception('文章详情页跳转到非目标网站，不能解析，continue。')

            # 已爬取过
            if News.objects.filter(post_id=_post_id, crawl_status=2):
                raise Exception(f'{_post_id}已存在，continue')

            # ▷︎请求一条新闻的详情页，返回html document
            # headers.update({'host': 'news.163.com'})
            resp = requests.get(_document_url, headers=headers, timeout=TIME_OUT)
            if resp.status_code != 200:
                raise Exception(f'请求新闻详情页失败,status_code {resp.status_code}, url {resp.url}')
            # 清洗html style和scripts，非必须。 https://lxml.de/lxmlhtml.html#cleaning-up-html
            # 试了下xpath的*not[name(.)!='script']没有成功，正则又复杂，最终找到了lxml包自带方法。
            html = resp.text
            cleaner = Cleaner(style=False, scripts=True, comments=True)
            html_filtered = cleaner.clean_html(html)
            html_tree = etree.HTML(html_filtered)
            # 正文 原始数据
            # xpath并未使用text()或@attr取到字符串信息。而整个标签元素etree element看字符串信息要先转化。
            # etree.tostring(element)返回byte类型还要decode(),这里使用tounicode()
            raw_content_pattern = '//div[@class="post_body"]/div[@class="post_text"]'
            content_elements = html_tree.xpath(raw_content_pattern)
            if not content_elements:
                logger.warning(f'新闻详情页为不支持的目标网站，不能解析，continue')
                continue
            content_element = content_elements[0]
            _content = etree.tounicode(content_element)
            logger.debug(f'_content: {_content}')

            # 持久化到News model。后面图片和评论均依赖news外键。
            news_row = {
                'post_id': _post_id,
                'raw_json': html,
                'title': _title,
                'publish_time': _publish_time,
                'document_url': _document_url,
                'comment_url': _comment_url,
                'content': _content,
                'crawl_status': 1,
                'catalog': catalog
            }
            news, created = News.objects.get_or_create(**news_row)  # 或 Catalog.news_set.add(News(**news_row))
            if not news:
                raise Exception(f'news插入数据库失败，news_row:{str(news_row)}')

            # 封面图和文章中图
            # 转为djangoFileField临时文件, 先save ImageField字段，存储在news post_id命名的文件夹下，依赖news实例先保存。
            if _cover_url:      # 有时封面图url为''
                resp = requests.get(_cover_url, headers=headers, timeout=TIME_OUT)
                if resp.status_code != 200:
                    raise Exception(f'\t文章封面图请求失败，cover_url{_cover_url}')
                image_bytes = resp.content
                _image_tmp_file = File(BytesIO(image_bytes))
                # url有时含图片处理参数 split('?')[0] 去除，同时适用不含参数的情况。 http://xxx.png?w=200&h=100
                _image_name = _cover_url.split('?')[0].split('/')[-1]
                image_row = {
                    'news': news,
                    'type': 1,  # 封面
                    'image': _image_tmp_file,
                    'raw_url': _cover_url,
                }
                image = Image(**image_row)
                image.image.save(name=_image_name, content=_image_tmp_file)     # 这句已调用image.save()下面一句可省略
                news.image_set.add(image)

            # ▷正文中的图片地址列表。八成新闻中无图片。虽然前端直接渲染网易资源地址也可以、可能用不到，但本地持久化一份\主要表现爬取知识。
            image_pattern = '//div[@class="post_body"]/div[@class="post_text"]/p[@class="f_center"]/img/@src'
            image_urls = html_tree.xpath(image_pattern)
            logger.debug(f'len(_image_urls):{len(image_urls)} _image_urls:{image_urls}')
            if image_urls:
                for i, _img_url in enumerate(image_urls):
                    resp = requests.get(_img_url, headers=headers, timeout=TIME_OUT)
                    if resp.status_code != 200:
                        raise Exception(f'\t文章内请求图片失败，img_url{_img_url}')
                    image_bytes = resp.content
                    # 图片二进制数据封装到django ImageField对象  https://docs.djangoproject.com/en/3.0/ref/files/file#django.core.files.File
                    _image_tmp_file = File(BytesIO(image_bytes))
                    _image_name = _img_url.split('?')[0].split('/')[-1]
                    # 持久化
                    image_row = {
                        'news': news,
                        'type': 2,  # 文章中插图
                        'image': _image_tmp_file,
                        'raw_url': _img_url,
                    }
                    image = Image(**image_row)
                    image.image.save(name=_image_name, content=_image_tmp_file)
                    news.image_set.add(image)
                    logger.info(f'\t文章内图片[{i+1}/{len(image_urls)}] 保存至 media/{_post_id}/{_image_name}')


            # ▷︎这条新闻的新闻评论，可以请求html页面，也可以请求分析得到的接口地址、不加参数时接口返回json document格式、参数非必须。
            # 只取热点评论接口<10条、不考虑所有评论，不考虑增量更新。
            # http://comment.api.163.com/api/v1/products/a2869674571f77b5a0867c3d71db5856/threads/FD7LSBB50001899O/comments/hotList?
            # ibc=newspc&limit=5&showLevelThreshold=72&headLimit=1&tailLimit=2&offset=0&callback=jsonp_1590133161790&_=1590133161791
            if post['tienum'] != 0:
                comment_api_url = f'http://comment.api.163.com/api/v1/products/a2869674571f77b5a0867c3d71db5856/threads/{_post_id}/comments/hotList'
                params = {'limit': '5'}
                resp = requests.get(comment_api_url, headers=headers, timeout=3)
                if resp.status_code != 200:
                    raise Exception(f'状态码异常 {resp.status_code}')
                comments_json = resp.text
                # json结构见 scratches/request_comments_response.json
                comments = json.loads(comments_json)
                comments_length = len(comments['commentIds'])
                logger.debug('comments: %s', comments)

                for i, c in enumerate(comments['comments'].values()):
                    try:
                        _comment_id = c['commentId']
                        _raw_json = json.dumps(c)
                        _content = c['content']
                        _publish_time = c['createTime']
                        _favorite_count = c['favCount']
                        _against_count = c['against']
                        _user_raw_id = c['user']['userId']
                        _user_avatar = c['user'].get('avatar', '')
                        _user_nickname = c['user'].get('nickname', '无名')
                        # 爬取的用户设定默认密码111111
                        _user_password = '111111'

                        # 持久化到自带的auth_user表，爬取用户作为假设的本地新用户。  _user_raw_id暂存放到first_name字段。
                        user_row = {
                            'username': _user_nickname,
                            'password': make_password(_user_password),
                            'first_name': _user_raw_id,
                        }
                        if not User.objects.filter(username=_user_nickname, first_name=_user_raw_id):
                            user = User.objects.create(**user_row)
                            logger.info(f'\t新增用户，user_raw_id {user.first_name}，user_name:{user.username}')

                        # 持久化评论
                        comment_row = {
                            'user': user,
                            'news': news,
                            'comment_id': _comment_id,
                            'raw_json': _raw_json,
                            'content': _content,
                            'publish_time': timezone.make_aware(datetime.strptime(_publish_time, '%Y-%m-%d %H:%M:%S')),
                            'favorite_count': _favorite_count,
                            'against_count': _against_count
                        }
                        comment, created = Comment.objects.get_or_create(**comment_row)
                        news.comment_set.add(comment)
                        logger.info(f'\t插入评论[{i+1}/{comments_length}]')
                    except Exception as e:
                        logger.exception(e)
                        continue

            # 一条新闻爬取完毕
            news.crawl_status = 2
            news.save()
            logger.info(f'新闻{_post_id}爬取完成 {_document_url}')
        except Exception as e:
            # 1）约五分之一概率 新闻详情页为另一个子站http://dy.163.com/v2/article/detail/FD58021805118HA4.html 与代码逻辑不符，忽略这种情况。
            # 2）超时。
            # 3）数据库。重复插入;某字段未取到值;正文含特殊字符、emoji表情。
            logger.error(f'存在异常，{e}, 新闻url: {_document_url}')
            logger.exception(e)
            continue

    time.sleep(5)
    logger.info('-'*100 + f'\n目录{_catalog}结束')
    # 测试暂时限制一个目录，注释以取消
    # break

logger.info('-'*100 + '\n全部结束🔚。')
